1. 首页 > 逻辑思维

全链路埋点化解方法:自建VTree技术解析 全链路分析

作者:admin 更新时间:2024-10-12
摘要:针对这个问题,我们自研了一套全链路埋点方案,从埋点设计、到客户端三端(iOS、Android、H5)开发、以及埋点校验&稽查、再到埋点数据使用,目前已经广泛应用,全链路埋点化解方法:自建VTree技术解析 全链路分析

 

本篇文章给大家谈谈全链路埋点解决方案:自建VTree技术解析,以及对应的知识点,文章可能有点长,但是希望大家可以阅读完,增长自己的知识,最重要的是希望对各位有所帮助,可以解决了您的问题,不要忘了收藏本站喔。针对这个问题,我们自研了一套全链路埋点方案,从埋点设计、到客户端三端(iOS、Android、H5)开发、以及埋点校验&稽查、再到埋点数据使用,目前已经广泛应用于云音乐各个主要APP。二、先聊聊传统埋点方案的弊端坑位的事件埋点很简单:点击/双击/滑动等明确的事件类埋点,很简单,根据需求一个一个埋上去即可资源位曝光埋点是噩梦:在列表/非列表资源的曝光埋点场景,想做到高精度(埋点精度提到99.99%)难度很大,你有可能每一个曝光埋点都需要考虑如下大部分场景:每个坑位都是独立的:坑位之间的埋点没有关系,需要给每一个坑位起名字(比如通过随机字符串,或者组合参数来标识),页面、列表、元素之间,存在大量的重复参数,以达到数据分析要求漏斗/归因分析难:由于每一个坑位埋点都是独立的,APP使用过程中先后产生的埋点是无关联的,想要做到漏斗/归因分析,需要客户端做魔鬼参数传递,然后数据分析时再逐个场景的做参数关联分析坑位黑盒:想知道一个app有多少坑位埋点,当前页面下已经显现出了多少坑位,坑位之间是什么关系,管理成本高三、我们曾经做过的一些尝试3.1无痕埋点市面上有很多人介绍无痕埋点,我们曾经也做过类似的尝试;这种无痕,主要是针对一些坑位事件(比如点击、双击、滑动等事件)埋点做自动生成埋点,同时附带上生成的xpath(根据view层级生成),然后把埋点上报到数据平台后,再将xpath赋予真实的业务意义,从而可以进行数据分析;但是这个方案的问题是只能处理一些简单事件场景,并且数据平台做xpath关联是一件噩梦,工作量大,最主要的是不稳定,对于埋点数据高精度场景,这个方案不可行(没有哪个客户端开发人员天天花费大量时间查找xpath是什么意义,以及随着迭代业务的开发,xpath由于不受控制的变化带来的数据问题带来的排查工作量是巨大的)。特别对于资源位的曝光上,想要做到真正的无痕,自动埋点,是不太可行的;比如列表场景,底层是不认识一个cell是什么资源的,甚至都也不知道是不是一个资源。四、我们的方案4.1对象对象是我们方案埋点管理和开发的基本单位,给一个UIView设置_oid(对象Id:ObjectId),该view就是一个对象;对象分为两大类,page&element;对象&参数page对象:比如UIViewController.view,WebView,或者一个半屏浮层的view,再或者一个业务弹窗element对象:比如UIButton,UICollectionViewCell,或者一个自定义view对象参数:对象是埋点具体信息的承载体,承载着对象维度的具体埋点参数对象的复用:对象的存在,其中一个很大的原因,就是需要做复用,对于一些通用UI组件,尤为合适4.2虚拟树(VTree)对象不是孤立存在的,而是以虚拟树(VTree)的方式组合在一起的,下面是一个示例:虚拟树VTree虚拟树VTree有如下特点:View树子集:原始view树层级很复杂,被标识成对象的称为节点,所有节点就组合成了VTree,是原始view树的子集上下文:虚拟树中的对象,是存在上下关系的,一个节点的所有祖先节点,就是该对象(节点)的上下文对象参数:有了节点的上下层级,不同维度的对象,只关心自己维度的参数,比如歌单详情页中歌曲cell不关心页面请求级别的歌单idSPM:节点及其所有祖先结点的oid组成了SPM值(其实还有position参数的参与,稍后再详解),该SPM可以唯一定位该节点持续生成:VTree是源源不断的构建的,每一个view发生了变化,View的添加/删除/层级变化/位移/大小变动/hidden/alpha,等等,都会引起重新构建一颗新的VTree五、埋点的产生上面的方案介绍完之后,你一定存在很多疑惑,有了对象,有了虚拟树,对象有了参数,埋点在哪儿?5.1先来看下埋点格式一个埋点除了有事件类型(action),埋点时间等一些基本信息之外,还得有业务埋点参数,以及能体现出对象上下级的结构先来看下一个普通埋点的格式:{:[{:,:,:}],:[{:,:,:}],:,:,:,:,:}_eventcode:埋点的类型,比如元素点击(_ec),元素曝光开始(_ev),元素曝光结束(_ed),页面曝光开始(_pv),页面曝光结束(_pd)等等_elist:从当前元素节点开始,向上所有元素节点的集合,是一个数组,倒叙_plist:从当前节点开始,向上所有页面结点的即可,是一个数组,倒叙_spm:上面已经介绍(SPM),可以唯一定位该坑位从上面的数据结构可以看出,数据结构是结构化的,坑位不是独立的,存在层级关系的5.2点击事件大部分的点击事件,都发生在如下四个场景上:UIView上添加的TapGesture单击手势UIControl的子类添加的TouchUpInside单击事件UITableViewCell的didSelectedRowAtIndexPath单击事件UICollectionViewCell的didSelectedItemAtIndexPath单击事件对于上述四种场景,我们采用了AOP的方式来内部承接掉,这里简单说明下如何做的;1.UIView:通过MethodSwizzling方式来进行对关键方法进行hock,当需要给view添加TapGesture时,顺便添加一个我们自己的TapGesture,这样我们就可以在点击事件触发的时候增加点击埋点,关键方法如下:initWithTarget:action:addTarget:action:removeTarget:action:1.对UIView点击事件的hock注意需要做到随着业务侧事件的增加/删除而一起增加/删除关键代码如下:@interfaceUIViewEventTracingAOPTapGesHandler:NSObject@property(nonatomic,assign)BOOLisPre;-(void)view_action_gestureRecognizerEvent:(UITapGestureRecognizer*)gestureRecognizer;@end@implementationUIViewEventTracingAOPTapGesHandler-(void)view_action_gestureRecognizerEvent:(UITapGestureRecognizer*)gestureRecognizer{if(![gestureRecognizerisKindOfClass:[UITapGestureRecognizerclass]]||gestureRecognizer.ne_et_validTargetActions.count==0){return;}UIView*view=gestureRecognizer.view;//for:preif(self.isPre){///MARK:这里是Pre代码位置return;}//for:after///MARK:这里是After代码位置}@interfaceUITapGestureRecognizer(AOP)@property(nonatomic,strong,setter=ne_et_setPreGesHandler:)UIViewEventTracingAOPTapGesHandler*ne_et_preGesHandler;///MARK:AddCategoryProperty@property(nonatomic,strong,setter=ne_et_setAfterGesHandler:)UIViewEventTracingAOPTapGesHandler*ne_et_afterGesHandler;///MARK:AddCategoryProperty@property(nonatomic,strong,readonly)NSMapTable*>*ne_et_validTargetActions;///MARK:AddCategoryProperty@end@implementationUITapGestureRecognizer(AOP)-(instancetype)ne_et_tap_initWithTarget:(id)targetaction:(SEL)action{if([self_ne_et_needsAOP]){[self_ne_et_initPreAndAfterGesHanderIfNeeded];}if(target&&action){UITapGestureRecognizer*ges=[selfinit];[selfaddTarget:targetaction:action];returnges;}return[selfne_et_tap_initWithTarget:targetaction:action];}-(void)ne_et_tap_addTarget:(id)targetaction:(SEL)action{if(!target||!action||![self_ne_et_needsAOP]||[[self.ne_et_validTargetActionsobjectForKey:target]containsObject:NSStringFromSelector(action)]){[selfne_et_tap_addTarget:targetaction:action];return;}SELhandlerAction=@selector(view_action_gestureRecognizerEvent:);//1.pre[self_ne_et_initPreAndAfterGesHanderIfNeeded];if(self.ne_et_validTargetActions.count==0){//第一个target+action被添加的时候,才添加pre[selfne_et_tap_addTarget:self.ne_et_preGesHandleraction:handlerAction];}[selfne_et_tap_removeTarget:self.ne_et_afterGesHandleraction:handlerAction];//保障after是最后一个,所以先行尝试删除一次//2.original[selfne_et_tap_addTarget:targetaction:action];NSMutableSet*actions=[self.ne_et_validTargetActionsobjectForKey:target]?:[NSMutableSetset];[actionsaddObject:NSStringFromSelector(action)];[self.ne_et_validTargetActionssetObject:actionsforKey:target];//3.after[selfne_et_tap_addTarget:self.ne_et_afterGesHandleraction:handlerAction];}-(void)ne_et_tap_removeTarget:(id)targetaction:(SEL)action{[selfne_et_tap_removeTarget:targetaction:action];NSMutableSet*actions=[self.ne_et_validTargetActionsobjectForKey:target];[actionsremoveObject:NSStringFromSelector(action)];if(actions.count==0){[self.ne_et_validTargetActionsremoveObjectForKey:target];}if(self.ne_et_validTargetActions.count>0){//删除当前target+action之后,还有其他的,则不需做任何处理,否则清理掉pre+afterreturn;}SELhandlerAction=@selector(view_action_gestureRecognizerEvent:);[selfne_et_tap_removeTarget:self.ne_et_preGesHandleraction:handlerAction];[selfne_et_tap_removeTarget:self.ne_et_afterGesHandleraction:handlerAction];}-(BOOL)_ne_et_needsAOP{returnself.numberOfTapsRequired==1&&self.numberOfTouchesRequired==1;}-(void)_ne_et_initPreAndAfterGesHanderIfNeeded{if(!self.ne_et_preGesHandler){UIViewEventTracingAOPTapGesHandler*preGesHandler=[[UIViewEventTracingAOPTapGesHandleralloc]init];preGesHandler.isPre=YES;self.ne_et_preGesHandler=preGesHandler;}if(!self.ne_et_afterGesHandler){self.ne_et_afterGesHandler=[[UIViewEventTracingAOPTapGesHandleralloc]init];}}@end2.UIControl:通过MethodSwizzling方式对关键方法进行hock,关键方法:sendAction:to:forEvent:对UIcontrol点击事件的hock需要注意业务侧添加了多个Target-Action事件,不能埋点埋了多次关键代码如下:@interfaceUIControl(AOP)@property(nonatomic,copy,readonly)NSMutableArray*ne_et_lastClickActions;///MARK:AddCategoryProperty@end@implementationUIControl(AOP)-(void)ne_et_Control_sendAction:(SEL)actionto:(id)targetforEvent:(UIEvent*)event{NSString*selStr=NSStringFromSelector(action);NSMutableArray*actions=@[].mutableCopy;[self.allTargetsenumerateObjectsUsingBlock:^(id_Nonnullobj,BOOL*_Nonnullstop){NSArray*actionsForTarget=[selfactionsForTarget:objforControlEvent:UIControlEventTouchUpInside];if(actionsForTarget.count){[actionsaddObjectsFromArray:actionsForTarget];}}];BOOLvalid=[actionscontainsObject:selStr];if(!valid){[selfne_et_Control_sendAction:actionto:targetforEvent:event];return;}//preif([self.ne_et_lastClickActionscount]==0){///MAKR:这里是Pre代码位置}[self.ne_et_lastClickActionsaddObject:[NSStringstringWithFormat:@,[targetclass],NSStringFromSelector(action)]];//original[selfne_et_Control_sendAction:actionto:targetforEvent:event];//afterif(self.ne_et_lastClickActions.count==actions.count){///MARK:这里是After代码位置[self.ne_et_lastClickActionsremoveAllObjects];}}@end3.UITableViewCell:先对setDelegate:进行hock,然后以NSProxy的形式将OriginalDelegate进行封装,组成DelegateChain的形式,然后在DelegateProxy内部做消息分发,从而可以完全掌控点击事件1.该DelegateChain的方式可以hock的不支持点击事件,可以hock所有Delegate的方法2.同样,也支持pre&after两个维度的hock3.特别注意:需要做到真正的DelegateChain,不然会跟不少三方库冲突,比如RXSwift,RAC,BlocksKit,IGListKit等关键示例代码几个重要的相关方法(代码较多不再展示,三方有多个库均可以借鉴):-(id)forwardingTargetForSelector:(SEL)selector;-(NSMethodSignature*)methodSignatureForSelector:(SEL)selector;-(void)forwardInvocation:(NSInvocation*)invocation;-(BOOL)respondsToSelector:(SEL)selector;-(BOOL)conformsToProtocol:(Protocol*)aProtocol;5.3曝光埋点曝光埋点在传统埋点场景下是最棘手的,很难做到高精度埋点,埋点时机总是穷举不完,即使有了完善的规范,开发人员还总是会遗漏场景我们这里的方案让开发者完全忽略曝光埋点的时机,开发者只把精力放在构建对象(或者说构建VTree),以及给对象添加参数上,下面看下是如何基于VTree做曝光的:持续构建VTree:前面提到,VTree是源源不断的构建的,每一个view发生了变化,View的添加/删除/层级变化/位移/大小变动/hidden/alpha,等等(这里均是AOP方式hock),都会引起重新构建一颗新的VTreeVTreeDiff:先后两个VTree的diff,就是我们曝光埋点的结果随着时间,会源源不断的生成新的VTree:远远不断地生成VTree比如T1时刻生成的VTree:T1时刻的VTreeT2时刻生成的VTree:T2时刻的VTree先后两颗VTree的diff:T1存在T2不存在的节点:3,4,6,7,8,11T1不存在T2存在的节点:20,21,22,23上面的diff结果,就是曝光埋点的结论曝光结束:3,4,6,7,8,11曝光开始:20,21,22,23从上面以及VTreeDiff的曝光策略,得出如下:这种策略,完全抹平了列表和非列表曝光时机问题,转而变成了何时构建VTree问题上资源是否曝光的问题,转而变成了VTree中节点的可见性问题上5.4埋点开发步骤基于VTree的埋点,不管是点击、滑动等事件埋点,还是元素、页面的曝光埋点,转化成了如下两个开发步骤:给View设置oid=>成为对象(构建VTree)第一步:给View设置oid给对象设置埋点参数第二步:给对象设置埋点参数六、VTree的构建6.1VTree构建过程构建一个VTree,是需要遍历原始view树的,构建过程中有如下特点:一个节点是否可见,跟view的hidden,alpha有关,并且必须添加到window上子节点的可见区域小于等于父节点的可见区域节点的可见区域,可以自定义的扩大或者缩小,就像UIButton的contentEdgeInsets那样修改可见区域节点是可以被遮挡的:一个page节点可以遮挡父节点名下添加顺序早于自己的其他节点被遮挡了从虚拟树上来看,被遮挡的结果:从虚拟树上来看,被遮挡的结果可打破原有view层级关系:可以手工干预上下层级关系,以做到逻辑挂载的能力>事实上,目前提供了三种逻辑挂载能力,这里简单提下,不做详细展开>1.手动逻辑挂载:指定将A挂载到B名下>2.自动逻辑挂载:将A挂载到当前rootPage(当前VTree最下层最右侧的page节点)名下>3.spm形式逻辑挂载:指定将A挂载到spm名下(对于解耦特别有用)虚拟父节点:可以给多个节点虚拟出一个父节点,对于双端UI差异时,但是要求同一套埋点结构时,很有用一个常见的例子,拿云音乐首页列表举例子,每一个模块的title和资源容器(内部可横向滑动),分别是一个cell;图中的浅红色(模块)其实没有一个UIView与之对应,业务侧埋点需要我们提供模块维度的曝光数据(但是Android开发过程中,通常都有UI与之对应)虚拟父节点精细化埋点:自定义可见区域&遮挡&节点的递归可见性结合起来,可以做到精细化埋点效果针对tabbar,navbar,再或者云音乐app底部的mini播放条等场景引起的列表cell是否曝光的问题,可做到精细化控制以及配合遮挡能力,真正做到了节点所见及曝光,不可见即曝光结束的效果6.2构建过程的性能考虑view的任何变化,都会引起VTree构建,看上去这是一件很恐怖的事情,因为每一次构建VTree都需要遍历整颗原始view树,我们做了如下优化来保障性能:主线程runloop空闲的时候构建VTree(而且需要该runloop已经运行的时间,小于等于16.7ms/3,这是拿固定帧率60帧举例)runloop构建限流器主线程runloop关键代码如下:///MARK:添加最小时长限流器_throtte=[[NEEventTracingTraversalRunnerDurationThrottlealloc]init];///至少间隔0.1s才做一次_throtte.tolerentDuration=0.1f;_throtte.callback=self;///MAKR:runloopobserverCFRunLoopObserverContextcontext={0,(__bridgevoid*)self,NULL,NULL,NULL};constCFIndexCFIndexMax=LONG_MAX;_runloopObserver=CFRunLoopObserverCreate(kCFAllocatorDefault,kCFRunLoopAllActivities,YES,CFIndexMax,&ETRunloopObserverCallback,&context);///MAKR:ObserverFuncvoidETRunloopObserverCallback(CFRunLoopObserverRefobserver,CFRunLoopActivityactivity,void*info){NEEventTracingTraversalRunner*runner=(__bridgeNEEventTracingTraversalRunner*)info;switch(activity){casekCFRunLoopEntry:[runner_runloopDidEntry];break;casekCFRunLoopBeforeWaiting:[runner.throttepushValue:nil];break;casekCFRunLoopAfterWaiting:[runner_runloopDidEntry];break;default:break;}}-(void)_runloopDidEntry{_currentLoopEntryTime=CACurrentMediaTime()*1000.f;}-(void)_needRunTask{CFTimeIntervalnow=CACurrentMediaTime()*1000.f;//如果本次主线程的runloop已经使用了了超过16.7/2.f毫秒,则本次runloop不再遍历,放在下个runloop的beforWaiting中//按照目前手机一秒60帧的场景,一帧需要1/60也就是16.7ms的时间来执行代码,主线程不能被卡住超过16.7ms//特别是针对iOS15之后,iPhone13ProMax帧率可以设置到120hzstaticCFTimeIntervalframeMaxAvaibleTime=0.f;staticdispatch_once_tonceToken;dispatch_once(&onceToken,^{NSIntegermaximumFramesPerSecond=60;if(@available(iOS10.3,*)){maximumFramesPerSecond=[UIScreenmainScreen].maximumFramesPerSecond;}frameMaxAvaibleTime=1.f/maximumFramesPerSecond*1000.f/3.f;});if(now-_currentLoopEntryTime>frameMaxAvaibleTime){return;}BOOLrunModeMatched=[[NSRunLoopmainRunLoop].currentModeisEqualToString:(NSString*)self.currentRunMode];///MARK:这里回调,开始构建VTree}列表滑动中局部虚拟树VTree局部构建VTree,可以大大减少构建一次VTree的工作量局部构建的前提时,距离上次构建虚拟树,发生变动的view都是ScrollView或者是ScrollView的子view列表滑动中限流器滚动中构建VTree6.3性能相关数据适当的曝光延后,满足数据要求,比如延迟1、2帧(取决于手机的性能以及当前CPU的工作量)runloop最小时长限流器的作用,还保障了延后不会太大,目前使用的0.1s用iPhone12手机,以云音乐首页复杂场景举例子,不停地上下滑动,全量/局部构建VTree分别大概需要3-8ms/1-2ms的样子,CPU占用2-3%左右(云音乐原来的列表曝光组件占用10%左右的CPU)不会因为SDK的存在,引起明显的主线程卡顿或者手机发烫七、链路追踪这个是SDK的重中之重的功能,目标是将app产生的所有埋点链起来,以协助数据侧统一一套模型即可分析漏斗/归因数据7.1链路追踪refer的含义refer是一段格式化的字符串,可以通过该字符串,在整个数仓中唯一定位到一个埋点,这就是链路追踪7.2如何定义一个埋点_sessid:每次app冷启动时生成,格式:[timestap]#[rand]#[appver]#[buildver]_pgstep:该app启动范围内,每一个page曝光,_pgstep+1_actseq:该rootPage曝光周期内,每一次交互事件(_pv也算一次事件),_actseq+1通过上述三个参数,即可定位某一次app启动&一次页面曝光周期内,哪一次的交互事件7.3先来看看如何认识一个埋点坑位[cid:ctype:ctraceid:ctrp]7.3refer格式解析格式:[_dkey:${keys}][F:${option}][sessid][e/p/xxx][_actseq][_pgstep][spm][scm]位option解析undefine-xpath:用以标识该refer指向的内容是被降级了的,随着埋点覆盖越来越全,有该标识的refer会越来越少7.4refer的使用先举一个典型的使用场景歌曲播放-refer过程解读:_addrefer_pgreferrefer的查找:自动向前查找:这是绝大部分使用的策略,自动向前在refer队列中找到合适的referundefine-xpath降级:如果找到的refer生成的时间,早于最后一次AOP捕获到的点击事件时间,则表明该位置没有埋点,说明refer不可信,则被降级到最后一次rootPage曝光所对应的refer上精确refer查找:也有多个策略的精确refer查找机制,不过使用起来不方便,没有被大范围使用7.5refer的统一解析根据上面refer的格式,数仓侧梳理出refer的格式统一解析,配合埋点管理平台,让规范化的漏斗/归因分析变为可能7.6其他refer使用场景multirefers:在实时分析场景,对一些关键埋点,带上了五级(甚至更多级)的refer数组,直接描述该操作的前五步做了什么(实时分析要求高,不能做离线数据关联)_rqrefer:让客户端埋点跟服务端埋点桥接了起来7.7refer对开发人员透明refer的复杂性:refer的复杂度很高,真实的refer处理比上述描述的还要复杂很多,对于普通客户端开发人员,想要完整理解,成本过于高开发时透明:对于开发人员来说,就是在对应的节点上增加相应的参数即可对象维度的三个标准私参(组成了_scm):cid,ctype,ctraceid,ctrp可平台校验:对象的事件是否参与链路追踪,参数完整性,等等,都可以在平台做合法性校验,进一步保障了refer的正确性八、H5、RNRN:做了一层桥接,可以在RN维度给view设置节点,同时设置参数RN桥接站内H5:采用了半白盒方案,H5内部局部虚拟树,所有埋点通过客户端SDK产生,H5埋点到达SDK后,在native侧做虚拟树融合,从而将站内H5跟native无缝地衔接了起来H5半白盒方案九、可视化工具客户端上传统的埋点都是看不见摸不着的,基于VTree的方案是结构化的,可以做到可视化查看埋点的数据,以及如何埋点的,下面是几个工具的截图可视化工具-埋点层级结构可视化工具-埋点数据十、埋点校验&稽查埋点是结构化的,虚拟树是在埋点平台管理起来的,埋点的校验,可以做到精确校验,校验出客户端的埋点虚拟树是否正确以及每一个对象上埋点的参数是否正确稽查:在测试包、灰度包中,对产生的所有埋点在平台侧做稽查,并输出稽查报告,在版本发布前,对有问题的埋点问题进行及时的修复,避免上线带来数据问题十一、落地该全链路埋点方案,已经全面在云音乐各个app铺开,并且P0场景已经完成数据侧切割,得到了充分的验证。十二、未来规划基于VTree可以做非常多的事情,比如: